Release Shark Explorer separately from LeakCanary - #2926
Merged
Conversation
The explorer is a desktop app, not a library, so it goes out as a signed macOS DMG on a GitHub release rather than to Maven Central, on `shark-explorer-*` tags and its own version line. It also has to have its own version line. Every installer format validates the version, and between them they leave only three integers with a major from 1 to 255: `3.0-alpha-10` builds nothing, and neither does `0.1.0`, which macOS rejects with "The first number in an app-version cannot be zero or negative". So no number can mean "before 1.0", and the alpha is said by the release being a prerelease titled that way instead. macOS signing goes through block/apple-codesign-action, which signs and notarizes with Block's Developer ID via an internal service the workflow reaches over OIDC. No certificate or Apple credential lives in this repository, which is what makes signing a public repo's artifacts safe. Windows and Linux build unsigned. The app tells the user when a newer release exists and does nothing else: no self-update, since replacing a running signed bundle needs a native helper and is a much bigger thing to get right than a link is. It reads one manifest off the release download CDN rather than the GitHub API, because `releases/latest` answers with the newest release of either line — usually a LeakCanary one — and because the unauthenticated API allows 60 requests an hour per IP, which a shared corporate egress can exhaust. Only promote-shark-explorer.yml writes that manifest, so publishing a release and offering it to everyone stay two separate acts. A release that turns out to be broken is then one nobody was told about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two release schedules means two change logs: someone reading either one is asking about one release line, and a Shark Explorer entry in the LeakCanary change log would show up in a LeakCanary release it has nothing to do with. The release process has to say so, or a release ships without an entry: cutting a version now includes renaming the Unreleased heading, and deploying the site, since the release notes link to the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub Advanced Security flagged both setup-gradle steps (zizmor's cache-poisoning audit). An Actions cache is writable from any branch of the repository and restorable by a tag build, so a cache entry is an untrusted input on a workflow whose output people download — and, on macOS, download signed and notarized as Block. cache-read-only would not fix it: restoring is the attack. A release runs a few times a year, so there is no build time worth that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A first run against the real signing service produced an app signed as Block with the hardened runtime and every entitlement — that Apple had no notarization record of, and that does not launch: it hangs in dyld with no output, where the same bundle re-signed ad hoc starts in two seconds. stapler is the only check that catches it. codesign is happy, and spctl answers "accepted, source=Developer ID" because nothing in the workflow carries the quarantine attribute that makes Gatekeeper insist on a ticket. So the warning becomes an error: a release nobody can launch is worse than no release. Also run the Windows job under bash. It defaulted to pwsh, where `./gradlew` does nothing and the step still passes, so the MSI was never built and the failure surfaced as a later step not finding a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both were guesses at a component before; now they are a function each. Signing an app whose name has a space works — it is the lambda's reply that fails — and notarization is skipped because notarytool's exit status is read in place of the status it reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pyricau
force-pushed
the
read-the-message-here
branch
from
August 4, 2026 18:47
6176433 to
6701535
Compare
Its own canary goes through the same pipeline and comes back notarized and stapled, so the earlier wording was too broad: the refusal is about this bundle, and what Apple objected to is not recoverable from the artifact. Note the one thing spctl does tell you while being a false green on the verdict: `source=Notarized Developer ID` against a plain `source=Developer ID`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Block's signing service is handed the .app as a zip named after packageName, and its lambda parses that name as a URI, so `Shark Explorer.app.zip` fails with `bad URI(is not URI?)` after a mac worker has already signed the app. squareup/tf-mobuild-workers#1365 fixes that; this is what gets a signed build in the meantime. Going back is one line, and it renames the .app for everyone who installed one, so it belongs in a release of its own rather than in the first commit after that lambda ships. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merged fix makes the pipeline fail on a refusal instead of returning an artifact that will not launch, which is what the retry did. Worth writing down that this is the fix working rather than a new problem, and that the reason Apple gave is only readable in Buildkite: the lambda turns any failed build into a generic 400, so the log output the fix added never reaches CI here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
skiko's runtime jar ships both architectures' dylibs. Compose extracts the one it packages for into the app directory and leaves the other inside the jar, where nothing loads it and nothing signing the bundle can reach it: a signer walks files, and that is an entry in a zip. Apple's notary service opens jars, so it was the one Mach-O arriving unsigned, and one is enough to have the whole app refused. Every file a signer can see was signed correctly, which is why no local check found this and why the DMG that came back passed codesign, spctl and its entitlements while hanging in dyld on launch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jlink includes only the modules this list names, and the list named one. So the update check — the only thing in the app that fetches anything — died on NoClassDefFoundError: java/net/http/HttpClient in every packaged build, logged once at startup and then never mentioned a new version again. `run` has the whole JDK on hand, so nothing about working on the app shows this. The four are what suggestRuntimeModules reports, which is the task to re-run when the dependencies change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The name stays one word. squareup/tf-mobuild-workers#1365 has merged, and a tagged build after that merge still failed on `bad URI(is not URI?)` about the same S3 key, because the lambda is Ruby that terraform packages: it serves the deployed zip until the rollout pipeline applies, and every check on that pull request is a plan. So the thing to wait for is the rollout rather than the merge, and a tagged build is what says whether it has happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pyricau
force-pushed
the
read-the-message-here
branch
from
August 4, 2026 21:56
9e9e689 to
d14d799
Compare
The name went to one word because a space in it reached Block's signing service as an S3 key and broke the reply that service sends back, five minutes after a mac worker had already signed the app. squareup/tf-mobuild-workers#1365 has now rolled out to production, so the name can say what it means. Done before the first release rather than after, since renaming the .app once anyone has installed one is a migration and right now nobody has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verifying the notarized DMG, a quarantined first launch produced no output, no log and no CPU for over five minutes, which is indistinguishable from the failure this page warns about — and was only the screen being locked. The same bundle and command started in four seconds unlocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Shark Explorer is a desktop app, not a library, so it ships as a signed macOS DMG on a GitHub release rather than to Maven Central. This adds the machinery for that: its own version line, its own tags, its own release workflow, and an in-app check that tells you when a newer release exists.
Nothing here changes anything about how LeakCanary itself is released.
The version can't say "alpha", and that isn't a preference
Every installer format validates the version, and I measured what they leave between them by building each one:
3.0-alpha-10Illegal version for 'Dmg', and forMsi. No qualifiers, in any format.0.1.0createDistributablefails:The first number in an app-version cannot be zero or negative2026.8.0,256.0.0Illegal version for 'Msi', whose fields cap at 255.255.655351.0.0,255.255.65535So the intersection is
MAJOR.MINOR.PATCHwith a major from 1 to 255. There is no number that means "before 1.0" — not0.x, not a calendar version, no qualifier. The alpha is said by the release instead:release-shark-explorer.ymlmarks every release as a prerelease and titles itShark Explorer <version> (alpha).Splitting it across
macOS.packageVersionandmacOS.packageBuildVersiondoesn't help either. The user-visible field isCFBundleShortVersionString, which is also what Munki compares for Managed Software Center updates, so pinning it would freeze updates for anyone who installed from there.Signing
Through
block/apple-codesign-action, which signs and notarizes with Block's Developer ID through an internal service the workflow reaches over OIDC. No certificate or Apple credential lives in this repository — that's what makes signing a public repo's artifacts safe, and it's howblock/qrgoandblock/buzzare signed.It needs two repository secrets,
OSX_CODESIGN_ROLEandCODESIGN_S3_BUCKET, provisioned by#mdx-ios. Until those exist themacosjobs will fail, so the first release can't be cut yet. Everything else in here works today.entitlements.plistisn't optional: every key in it is something the JVM does that the hardened runtime forbids, so a notarized build without them launches and immediately dies.The service signs the
.appand rebuilds the DMG around it, so the DMG container itself stays unsigned. The workflow runscodesign --verify,stapler validateandspctl --assesson the app inside the DMG and prints what they say, rather than assuming.The update check reports and nothing else
A bar naming the new version, a link, and a way to dismiss it. No self-update: replacing a running signed bundle needs a native helper, and that's a much bigger thing to get right than a link.
Two decisions worth calling out:
releases/latest. GitHub has one "latest" pointer per repository, and this repo publishes LeakCanary onv*tags, so that endpoint answers with the wrong release — it currently returnsv3.0-alpha-9. The unauthenticated API is also 60 requests an hour per IP, which a shared corporate egress can exhaust. The app reads one small manifest off the release download CDN, which is unmetered.promote-shark-explorer.ymlwrites that manifest. So publishing a release and offering it to everyone are two separate acts, and a release that turns out to be broken is one nobody was told about rather than one that has to be withdrawn.Its own change log
Two release schedules means two change logs, so
docs/shark-explorer-changelog.mdis new and a Shark Explorer change never goes in the LeakCanary one — it would otherwise show up under a LeakCanary release it has nothing to do with. Same markers, minus 🐤, which is a library thing. It starts at## Unreleasedwith* ✨ Initial release.A change log nobody is told to update ships empty, so the release process now says so: cutting a version includes renaming the
Unreleasedheading, and there's a step to deploy the site, since the release notes link to the page.Verified, not assumed
./gradlew buildgreen;:shark:shark-explorer:shark-explorer-app:checkgreen with 22 new tests.packageDmgbuilds a 69 MB DMG whoseInfo.plistcarriescom.squareup.leakcanary.shark-explorerand version1.0.0, with the generated version resource inside the packaged jar.The app launched and the live check ran against the real URL:
latest.properties answered 404, logged, no bar, on a worker thread. That's the expected answer until the first promotion.Both workflows parse, and the tag/version check and manifest-writing shell were run locally.
UpdateCheckTestpins the manifest format from the app's side, since the workflow writes it in bash and nothing else keeps the two agreeing.mkdocs buildrenders both new pages with no broken links; the only warnings are the pre-existing ones fordocs/api, whichsiteDokkagenerates and isn't committed.Known gap
There's no user-facing Shark Explorer docs page yet, so the release notes describe the app rather than linking to one. Worth writing before the first release goes out, since discoverability is the point — it's being written separately and will land in the same
Shark Explorernav section as the change log.🤖 Generated with Claude Code